你好,我是姚秋辰。
上一节课我们搭建了 coupon-template-serv 模块,实现了优惠券模板的创建和批量查询等功能,相信你已经对如何使用 Spring Boot 搭建应用驾轻就熟了。今天我们就来搭建优惠券平台项目的另外两个模块,coupon-calculation-serv(优惠计算服务)和 coupon-customer-serv(用户服务),组建一个完整的实战项目应用(middleware 模块将在 Spring Cloud 环节进行搭建)。
通过今天的课程,你可以巩固并加深 Spring Boot 的实操能力,为接下来 Spring Cloud 微服务化改造打好前置知识的基础,在这节课里我也会分享一些关于设计模式和数据冗余的经验之谈。
另外,这节课的源码都可以在Gitee 代码库中找到。你可不要只读爽文不动手敲代码,我建议你把代码下载到本地,对照着源码动手练习一遍,才能学为己用。 闲话少叙,我们根据优惠券项目的依赖关系,先从上游服务 coupon-calculation-serv 开始动手搭建吧。
搭建 coupon-calculation-serv
coupon-calculation-serv 提供了用于计算订单的优惠信息的接口,它是一个典型的“计算密集型”服务。所谓计算密集型服务一般具备下面的两个特征:
在做大型应用架构的时候,我们通常会把计算密集型服务与 IO/ 存储密集型服务分割开来,这样做的一个主要原因是提高资源利用率。
比如说,我们有一个计算密集型的微服务 A 和一个 IO 密集型微服务 B,大促峰值流量到来的时候,如果微服务 A 面临的压力比较大,我可以专门调配高性能 CPU 和内存等“计算类”的资源去定向扩容 A 集群;如果微服务 B 压力吃紧了,我可以定向调拨云上的存储资源分配给 B 集群,这样就实现了一种“按需分配”。
假如微服务 A 和微服务 B 合二为一变成了一个服务,那么在分配资源的时候就无法做到定向调拨,全链路压测环节也难以精准定位各项性能指标,这难免出现资源浪费的情况。这也是为什么,我要把优惠计算这个服务单独拿出来的原因。
现在,我们开始着手搭建 coupon-calculation-serv 下的子模块。和 coupon-template-serv 结构类似,coupon-calculation-serv 下面也分了若干个子模块,包括 API 层和业务逻辑层。API 层定义了公共的 POJO 类,业务逻辑层主要实现优惠价格计算业务。因为 calculation 服务并不需要访问数据库,所以没有 DAO 模块。
根据子模块间的依赖关系,我们就先从 coupon-calculation-api 这个接口层子模块开始搭建吧。
搭建 coupon-calculation-api
如果 coupon-calculation-serv 需要计算订单的优惠价格,那就得知道当前订单用了什么优惠券。封装了优惠券信息的 Java 类 CouponInfo 位于 coupon-template-api 包下,因此我们需要把 coupon-template-api 的依赖项加入到 coupon-calculation-api 中。
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-api</artifactId>
<version>${project.version}</version>
</dependency>
添加好了依赖项之后,接下来我们定义用于封装订单信息的 ShoppingCart 类。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ShoppingCart {
@NotEmpty
private List<Product> products;
private Long couponId;
private List<CouponInfo> couponInfos;
private long cost;
@NotNull
private Long userId;
}
在上面的源码中,我们看到 ShoppingCart 订单类中使用了 Product 对象,来封装当前订单的商品列表。在 Product 类中包含了商品的单价、商品数量,以及当前商品的门店 ID。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
private long price;
private Integer count;
private Long shopId;
}
在电商领域中,商品的数量通常不能以 Integer 整数来表示,这是因为只有标品才能以整数计件。对于像蔬菜、肉类等非标品来说,它们的计件单位并不是“个”。所以在实际项目中,尤其是零售行业的业务系统里,计件单位要允许小数位的存在。而我们的实战项目为了简化业务,就假定所有商品都是“标品”了。
在下单的时候,你可能有多张优惠券可供选择,你需要通过“价格试算”来模拟计算每张优惠券可以扣减的金额,进而选择最优惠的券来使用。SimulationOrder 和 SimulationResponse 分别代表了“价格试算”的订单类,以及返回的计算结果 Response。我们来看一下这两个类的源码。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SimulationOrder {
@NotEmpty
private List<Product> products;
@NotEmpty
private List<Long> couponIDs;
private List<CouponInfo> couponInfos;
@NotNull
private Long userId;
}
@Data
@NoArgsConstructor
public class SimulationResponse {
private Long bestCouponId;
private Map<Long, Long> couponToOrderPrice = Maps.newHashMap();
}
到这里,coupon-calculation-api 模块就搭建好了。因为 calculation 服务不需要访问数据库,所以我们就不用搭建 dao 模块了,直接来实现 coupon-calculation-impl 业务层的代码逻辑。
搭建 coupon-calculation-impl
首先,我们在 coupon-calculation-impl 的 pom.xml 文件中添加下面的三个依赖项。
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-calculation-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
从 coupon-template-api 和 coupon-calculation-api 两个依赖项中,你可以拿到订单优惠计算过程用到的 POJO 对象。接下来,我们可以动手实现优惠计算逻辑了。
在搭建优惠计算业务逻辑的过程中,我运用了模板设计模式来封装计算逻辑。模板模式是一种基于抽象类的设计模式,它的思想很简单,就是将共性的算法骨架部分上升到抽象层,将个性部分延迟到子类中去实现。
优惠券类型有很多种,比如满减券、打折券、随机立减等等,这些券的计算流程(共性部分)是相同的,但具体的计算规则(个性部分)是不同的。我将共性的部分抽象成了 AbstractRuleTemplate 抽象类,将各个券的差异性计算方式做成了抽象类的子类。
让我们看一下计算逻辑的类结构图。
在这张图里,顶层接口 RuleTemplate 定义了 calculate 方法,抽象模板类 AbstractRuleTemplate 将通用的模板计算逻辑在 calculate 方法中实现,同时它还定义了一个抽象方法 calculateNewPrice 作为子类的扩展点。各个具体的优惠计算类通过继承 AbstractRuleTemplate,并实现 calculateNewPrice 来编写自己的优惠计算方式。
我们先来看一下 AbstractRuleTemplate 抽象类的代码,走读 calculate 模板方法中的计算逻辑实现。
public ShoppingCart calculate(ShoppingCart order) {
Long orderTotalAmount = getTotalPrice(order.getProducts());
Map<Long, Long> sumAmount = getTotalPriceGroupByShop(order.getProducts());
CouponTemplateInfo template = order.getCouponInfos().get(0).getTemplate();
Long threshold = template.getRule().getDiscount().getThreshold();
Long quota = template.getRule().getDiscount().getQuota();
Long shopId = template.getShopId();
Long shopTotalAmount = (shopId == null) ? orderTotalAmount : sumAmount.get(shopId);
if (shopTotalAmount == null || shopTotalAmount < threshold) {
log.debug("Totals of amount not meet");
order.setCost(orderTotalAmount);
order.setCouponInfos(Collections.emptyList());
return order;
}
Long newCost = calculateNewPrice(orderTotalAmount, shopTotalAmount, quota);
if (newCost < minCost()) {
newCost = minCost();
}
order.setCost(newCost);
log.debug("original price={}, new price={}", orderTotalAmount, newCost);
return order;
}
在上面的源码中,我们看到大部分计算逻辑都在抽象类中做了实现,子类只要实现 calculateNewPrice 方法完成属于自己的订单价格计算就好。我们以满减规则类为例来看一下它的实现。
@Slf4j
@Component
public class MoneyOffTemplate extends AbstractRuleTemplate implements RuleTemplate {
@Override
protected Long calculateNewPrice(Long totalAmount, Long shopAmount, Long quota) {
Long benefitAmount = shopAmount < quota ? shopAmount : quota;
return totalAmount - benefitAmount;
}
}
在上面的源码中,我们看到子类业务的逻辑非常简单清爽。通过模板设计模式,我在抽象类中封装了共性逻辑,在子类中扩展了可变逻辑,每个子类只用关注自己的特定实现即可,使得代码逻辑变得更加清晰,大大降低了代码冗余。
随着业务发展,你的优惠券模板类型可能会进一步增加,比如赠品券、随机立减券等等,如果当前的抽象类无法满足新的需求,你可以通过建立多级抽象类的方式进一步增加抽象层次,不断将共性不变的部分抽取为抽象层。
创建完优惠计算逻辑,我们接下来看一下 Service 层的代码实现逻辑。Service 层的 calculateOrderPrice 代码非常简单,通过 CouponTemplateFactory 工厂类获取到具体的计算规则,然后调用 calculate 计算订单价格就好了。simulate 方法实现了订单价格试算,帮助用户在下单之前了解每个优惠券可以扣减的金额,从而选出最省钱的那个券。
@Slf4j
@Service
public class CouponCalculationServiceImpl implements CouponCalculationService {
@Override
public ShoppingCart calculateOrderPrice(@RequestBody ShoppingCart cart) {
log.info("calculate order price: {}", JSON.toJSONString(cart));
RuleTemplate ruleTemplate = couponTemplateFactory.getTemplate(cart);
return ruleTemplate.calculate(cart);
}
@Override
public SimulationResponse simulate(@RequestBody SimulationOrder order) {
SimulationResponse response = new SimulationResponse();
Long minOrderPrice = Long.MIN_VALUE;
for (CouponInfo coupon : order.getCouponInfos()) {
ShoppingCart cart = new ShoppingCart();
cart.setProducts(order.getProducts());
cart.setCouponInfos(Lists.newArrayList(coupon));
cart = couponProcessorFactory.getTemplate(cart).calculate(cart);
Long couponId = coupon.getId();
Long orderPrice = cart.getCost();
response.getCouponToOrderPrice().put(couponId, orderPrice);
if (minOrderPrice > orderPrice) {
response.setBestCouponId(coupon.getId());
minOrderPrice = orderPrice;
}
}
return response;
}
}
在上面的源码中,我们看到,优惠券结算方法不用关心订单上使用的优惠券是满减券还是打折券,因为工厂方法会将子类转为顶层接口 RuleTemplate 返回。在写代码的过程中,我们也要有这样一种意识,就是尽可能对上层业务屏蔽其底层业务复杂度,底层具体业务逻辑的修改对上层是无感知的,这其实也是开闭原则的思想。
完成 Service 层后,我们接下来新建一个 CouponCalculationController 类,对外暴露 2 个 POST 接口,第一个接口完成订单优惠价格计算,第二个接口完成优惠券价格试算。
@Slf4j
@RestController
@RequestMapping("calculator")
public class CouponCalculationController {
@Autowired
private CouponCalculationService couponCalculationService;
@PostMapping("/checkout")
@ResponseBody
public ShoppingCart calculateOrderPrice(@RequestBody ShoppingCart settlement) {
log.info("do calculation: {}", JSON.toJSONString(settlement));
return couponCalculationService.calculateOrderPrice(settlement);
}
@PostMapping("/simulate")
@ResponseBody
public SimulationResponse simulate(@RequestBody SimulationOrder order) {
log.info("do simulation: {}", JSON.toJSONString(order));
return couponCalculationService.simulateOrder(order);
}
}
好了,现在你已经完成了所有业务逻辑的源码。最后一步画龙点睛,你还需要为 coupon-calculation-impl 应用创建一个 Application 启动类并添加 application.yml 配置项。因为它并不需要访问数据库,所以你不需要在配置文件或者启动类注解上添加 spring-data 的相关内容。
到这里,我们就完成了优惠计算服务的搭建工作,你可以到我的代码仓库中查看完整的 coupon-calculation-serv 源码实现。 下面,我们去搭建优惠券项目的最后一个服务:coupon-customer-serv。
搭建 coupon-customer-serv
coupon-customer-serv 是一个服务于用户的子模块,它的结构和 coupon-template-serv 一样,包含了 API 层、DAO 层和业务逻辑层。它实现了用户领券、用户优惠券查找和订单结算功能。
为了简化业务逻辑,我在源码里省略了“用户注册”等业务功能,使用 userId 来表示一个已注册的用户。
按照惯例,我们先从 API 层开始搭建,搭建 coupon-customer-api 的过程非常简单。
搭建 coupon-customer-api
首先,我们需要把 coupon-template-api 和 coupon-calculation-api 这两个服务的依赖项添加到 coupon-customer-api 的 pom 依赖中,这样一来 customer 服务就可以引用到这两个服务的 Request 和 Response 对象了。
接下来,我们在 API 子模块中创建一个 RequestCoupon 类,作为用户领取优惠券的请求参数,通过传入用户 ID 和优惠券模板 ID,用户可以领取一张由指定模板打造的优惠券。另一个类是 SearchCoupon,用来封装优惠券查询的请求参数。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RequestCoupon {
@NotNull
private Long userId;
@NotNull
private Long couponTemplateId;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SearchCoupon {
@NotNull
private Long userId;
private Long shopId;
private Integer couponStatus;
}
到这里,coupon-customer-api 就搭建完了。接下里我们去搭建 coupon-customer-dao 层,从数据层实现用户优惠券的增删改查。
搭建 coupon-customer-dao
我在 DAO 子模块中创建了一个 Coupon 数据库实体对象用于保存用户领到的优惠券,并按照 spring-data-jpa 规范创建了一个 CouponDAO 接口用来提供 CRUD 操作。
我们先来看一下 Coupon 实体对象的内容。
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "coupon")
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "template_id", nullable = false)
private Long templateId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "shop_id")
private Long shopId;
@CreatedDate
@Column(name = "created_time", nullable = false)
private Date createdTime;
@Column(name = "status", nullable = false)
@Convert(converter = CouponStatusConverter.class)
private CouponStatus status;
@Transient
private CouponTemplateInfo templateInfo;
}
在上面的源码中,我在 class 级别使用了 Lombok 注解自动生成代码,如果你对 Lomkob 比较感兴趣,可以从Lomkob 官网上获取更多的使用方法。 从这段代码引申一下,我想和你分享一个关于“数据冗余”的小知识点。我们看到 Coupon 实体对象中冗余保存了一个 Shop ID,之所以说它是冗余字段,是因为 Shop ID 可以从 CouponTemplate 表中获取,顺着 Coupon 对象的 templateID 字段可以关联到 CouponTemplate 表,进而获取到 ShopID 对象。
那我们为什么需要在 Coupon 表中再保存一次 shop ID 呢?如果严格遵循数据库的范式,那确实不应该保存一个冗余的 shop ID 字段,但我们也不要忘了,所谓范式和规则就是留给后人打破的。
数据库的标准范式是上一个时代的产物,以那个时代的眼光来看,“存储”是一项很宝贵的资源,在做程序设计的时候应该尽可能节省磁盘空间、内存空间,反倒“性能”和“高并发”并不是需要担心的事情。
当我们用现在的眼光来审视程序设计,你会发现“存储资源”已经不再是制约生产力的瓶颈,为了应对高并发的场景,你必须尽可能提高系统的吞吐量和性能。
因此,你经常可以看到一二线大厂的高并发系统大量使用了“数据冗余”和“数据异构”方案。这是一个“以空间换时间”的路子,通过将一份数据冗余或异构到多处,提升业务的查询和处理效率。
了解了数据冗余的扩展知识后,我们来看下 DAO 层的接口类的内容:
public interface CouponDao extends JpaRepository<Coupon, Long> {
long countByUserIdAndTemplateId(Long userId, Long templateId);
}
在上面的源码中,我们只创建了一个接口用于 count 计算,至于其他增删改查功能则统一由父类 JpaRepository 一手包办了。spring-data-jpa 沿袭了 spring 框架的简约风,大道至简解放双手,整个 Spring 框架从诞生至今,也一直都在朝着不断简化的方向发展。
到这里,coupon-customer-dao 层的代码就写完了,接下来我们去搞定最后一个子模块 coupon-customer-impl 业务逻辑层。
搭建 coupon-customer-impl
既然 coupon-customer-impl 需要调用 template 和 calculation 两个服务,在没有进入微服务化改造之前,我们只能先暂时委屈一下 template 和 calculation,将它俩作为 customer 服务的一部分,做成一个三合一的单体应用。等你学到微服务课程的时候,这个单体应用会被拆分成独立的微服务模块。
首先,你需要将 template、calculation 的依赖项添加到 coupon-customer-impl 的配置文件中,注意这里我们添加的可不是 API 接口层的依赖,而是 Impl 接口实现层的依赖。
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-customer-dao</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-calculation-impl</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-impl</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
添加完依赖项之后,我们就可以去动手实现业务逻辑层了。
CouponCustomerService 是业务逻辑层的接口抽象,我添加了几个方法,用来实现用户领券、查询优惠券、下单核销优惠券、优惠券试算等功能。
public interface CouponCustomerService {
Coupon requestCoupon(RequestCoupon request);
ShoppingCart placeOrder( info);
SimulationResponse simulateOrderPrice(SimulationOrder order);
void deleteCoupon(Long userId, Long couponId);
List<CouponInfo> findCoupon(SearchCoupon request);
}
这里,我以 placeOrder 方法为例,带你走读一下它的源码。如果你对其它方法的源码感兴趣,可以到Gitee 源码库中找到 Spring Boot 急速落地篇的 CouponCustomerServiceImpl 类,查看源代码。 placeOrder 方法实现了用户下单 + 优惠券核销的功能,我们来看一下它的实现逻辑。
@Override
@Transactional
public ShppingCart placeOrder(ShppingCart order) {
if (CollectionUtils.isEmpty(order.getProducts())) {
log.error("invalid check out request, order={}", order);
throw new IllegalArgumentException("cart is empty");
}
Coupon coupon = null;
if (order.getCouponId() != null) {
Coupon example = Coupon.builder().userId(order.getUserId())
.id(order.getCouponId())
.status(CouponStatus.AVAILABLE)
.build();
coupon = couponDao.findAll(Example.of(example)).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("Coupon not found"));
CouponInfo couponInfo = CouponConverter.convertToCoupon(coupon);
couponInfo.setTemplate(templateService.loadTemplateInfo(coupon.getTemplateId()));
order.setCouponInfos(Lists.newArrayList(couponInfo));
}
ShppingCart checkoutInfo = calculationService.calculateOrderPrice(order);
if (coupon != null) {
if (CollectionUtils.isEmpty(checkoutInfo.getCouponInfos())) {
log.error("cannot apply coupon to order, couponId={}", coupon.getId());
throw new IllegalArgumentException("coupon is not applicable to this order");
}
log.info("update coupon status to used, couponId={}", coupon.getId());
coupon.setStatus(CouponStatus.USED);
couponDao.save(coupon);
}
return checkoutInfo;
}
在上面的源码中,我们看到 Coupon 对象的构造使用了 Builder 链式编程的风格,这是得益于在 Coupon 类上面声明的 Lombok 的 Builder 注解,只用一个 Builder 注解就能享受链式构造的体验。
搞定了业务逻辑层后,接下来轮到 Controller 部分了,我在 CouponCustomerController 中对外暴露了几个服务,这些服务调用 CouponCustomerServiceImpl 中的方法实现各自的业务逻辑。
@Slf4j
@RestController
@RequestMapping("coupon-customer")
public class CouponCustomerController {
@Autowired
private CouponCustomerService customerService;
@PostMapping("simulateOrder")
public SimulationResponse simulate(@Valid @RequestBody SimulationOrder order) {
return customerService.simulateOrderPrice(order);
}
@DeleteMapping("deleteCoupon")
public void deleteCoupon(@RequestParam("userId") Long userId,
@RequestParam("couponId") Long couponId) {
customerService.deleteCoupon(userId, couponId);
}
@PostMapping("checkout")
public ShppingCart checkout(@Valid @RequestBody ShppingCart info) {
return customerService.placeOrder(info);
}
}
以上,就是所有的业务逻辑代码部分了。接下来你只需要完成启动类和配置文件,就可以启动项目做测试了。我先来带你看一下启动类的部分:
@SpringBootApplication
@EnableJpaAuditing
@ComponentScan(basePackages = {"com.geekbang"})
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = {"com.geekbang"})
@EntityScan(basePackages = {"com.geekbang"})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
在上面的源码中,我们看到很多注解上都注明了 com.geekbang 作为包路径。之所以这么做,是因为 Spring Boot 的潜规则是将当前启动类类所在 package 作为扫包路径。
如果你的 Application 在 com.geekbang.customer 下,而你在项目中又需要加载来自 com.geekbang.template 下的类资源,就必须额外声明扫包路径,否则只有在 com.geekbang.customer 和其子路径之下的资源才会被加载。
关于配置项的部分,你可以直接把 coupon-template-impl 的配置文件 application.yml 照搬过来,不过,要记得把里面配置的 spring.application.name 改成 coupon-customer-serv。
好,到这里,我们优惠券平台项目的 Spring Boot 版本就搭建完成了。现在,coupon-customer-serv 已经成了一个三合一的单体应用,你只要在本地启动这一个应用,就可以调用 customer、template 和 calculation 三个服务的功能。
总结
现在,我们来回顾一下这两节 Spring Boot 实战课的重点内容。通过这两节课,我带你搭建了完整的 Spring Boot 版优惠券平台的三个子模块。为了让项目结构更加清晰,我用分层设计的思想将每个模块拆分成 API 层、DAO 层和业务层。在搭建过程中,我们使用 spring-data-jpa 搞定了数据层,短短几行代码就能实现复杂的 CRUD 操作;使用 spring-web 搭建了 Controller 层,对外暴露了 RESTFul 风格的接口。
我们学习技术也分为外功修为和内功修行,讲究的是内外兼修。技术框架总会不断推陈出新,学会怎么使用一门技术,这修习的是外功。你掌握了一个功能强大的新框架,外功招式自然凌厉几分。但是能决定你武力值的上限有多高,还要靠你在工作学习中不断提高内功修为。
外功见效快而内功需要长期磨炼,就像我这节课分享的设计模式一样,设计模式就是典型的内功心法,学会一两种设计模式不会让你的技术水平产生突飞猛进的提高,但是当你逐渐融会贯通把各种设计模式活学活用到代码中,境界层次就变得不一样了。
从下一节课开始,我们将进入 Spring Cloud 基础篇的学习,通过基础篇的学习,你将熟练使用 Nacos、Loadbalancer 和 OpenFeign 组件来搭建基于微服务架构的跨服务调用。
思考题
如果我们分别把 coupon-customer-serv、coupon-template-serv 和 coupon-calculation-serv 分别部署在集群 A、B 和 C 上,你能想到几种方式,使得这几个应用可以在集群环境中互相发起调用呢?
我给你一个小提示,在思考这个问题的时候,你要想到一点,服务有可能会发生上下线而且集群也可能会扩容,要尽可能让调用请求发到正常工作的机器上,提高请求成功率。欢迎你在留言区分享你的想法和收获,我在留言区等你。
好啦,这节课就结束啦。也欢迎你把这节课分享给更多对 Spring Cloud 感兴趣的朋友。我是姚秋辰,我们下节课再见!